package de.zainodis.commons.utils;

import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertNotNull;
import static junit.framework.Assert.assertTrue;

import java.text.DateFormat;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
import java.util.TimeZone;

import junit.framework.AssertionFailedError;
import de.zainodis.commons.LogCat;

public class DateTimeUtils {

   private static final String TAG = "DateTimeUtils";

   /**
    * Regex to parse minutes. Allowed values are values between 0 and 59. If the
    * value is a single digit (0 to 9) it needs to be preceded by a zero.
    */
   public static final String TIME_MINUTES_PARSE_EXPRESSION = "(0[0-9]{1}|[1-5]{1}[0-9]{1})";

   /**
    * Regex to parse hours. Hours are values between 0 and 23.
    */
   public static final String TIME_HOURS_PARSE_EXPRESSION = "(0[0-9]{1}|[1]{1}[0-9]{1}|[2]{1}[0-3]{1})";

   /**
    * Regex to parse seconds. Seconds are values between 0 and 59. If the value
    * is a single digit (0 to 9) it needs to be preceded by a zero.
    */
   public static final String TIME_SECONDS_PARSE_EXPRESSION = "(0[0-9]{1}|[1-5]{1}[0-9]{1})";

   /**
    * Regex to parse a time {@link String} in the format HH:mm.
    */
   public static final String TIME_WITH_SEPARATOR_PARSE_EXPRESSION = String.format("%s:%s",
	    TIME_HOURS_PARSE_EXPRESSION, TIME_MINUTES_PARSE_EXPRESSION);

   /**
    * Regex to parse a time {@link String} in the format H:mm.
    */
   public static final String TIME_SINGLE_HOUR_WITH_SEPARATOR_PARSE_EXPRESSION = String.format(
	    "%s:%s", "([0-9]{1})", TIME_MINUTES_PARSE_EXPRESSION);

   /**
    * Regex to parse days of a month. Days of a month are values between 1 and
    * 31.
    */
   public static final String DATE_DAYS_PARSE_EXPRESSION = "(0[1-9]{1}|[1-2]{1}[0-9]{1}|[3]{1}[0-1]{1})";

   /**
    * Regex to parse months. Months are values between 1 and 12.
    */
   public static final String DATE_MONTHS_PARSE_EXPRESSION = "(0[1-9]{1}|[1]{1}[0-2]{1})";

   /**
    * Regex to parse years. Years are either 2 or 4 digits.
    */
   public static final String DATE_YEARS_PARSE_EXPRESSION = "(\\d{2}|\\d{4})";

   /**
    * Regex to parse a time {@link String} in the format HHmm.
    */
   public static final String TIME_WITHOUT_SEPARATOR_PARSE_EXPRESSION = TIME_HOURS_PARSE_EXPRESSION
	    + TIME_MINUTES_PARSE_EXPRESSION;

   /**
    * Regex to parse a time {@link String} in the format Hmm.
    */
   public static final String TIME_SINGLE_HOUR_WITHOUT_SEPARATOR_PARSE_EXPRESSION = "([0-9]{1})"
	    + TIME_MINUTES_PARSE_EXPRESSION;

   /**
    * Regex to be matched against the user's date input. This format expects the
    * date separator <b>.</b> and the format dd.MM.yyyy or dd.MM.yy
    */
   public static final String DATE_REGEX_FORMAT_WITH_SEPARATOR = String.format("%s\\.%s\\.%s",
	    DATE_DAYS_PARSE_EXPRESSION, DATE_MONTHS_PARSE_EXPRESSION, DATE_YEARS_PARSE_EXPRESSION);

   /**
    * Regex to be matched against the user's date input. This format requires no
    * separator. Possible formats are ddMMyyyy or ddMMyy
    */
   public static final String DATE_REGEX_FORMAT_WITHOUT_SEPARATOR = String.format("%s%s%s",
	    DATE_DAYS_PARSE_EXPRESSION, DATE_MONTHS_PARSE_EXPRESSION, DATE_YEARS_PARSE_EXPRESSION);

   /**
    * Regex to be matched against a date time duration. This format requires
    * separators. The format is: d*.HH:mm:ss
    */
   public static final String TIMESPAN_REGEX_WITH_SEPARTOR = String.format("%s\\.%s:%s:%s",
	    "(\\d{2})", TIME_HOURS_PARSE_EXPRESSION, TIME_MINUTES_PARSE_EXPRESSION,
	    TIME_SECONDS_PARSE_EXPRESSION);

   /** The format is "HH:mm 'Uhr'". */
   public static final String TIME_FORMAT_WITH_OCLOCK = "HH:mm 'Uhr'";

   /** The format is "HH:mm". */
   public static final String TIME_FORMAT = "HH:mm";

   /** The format is "HH:mm". */
   public static final String TIME_FORMAT_NO_SEPARATOR = "HHmm";

   /** The format is "dd.HH:mm:ss". */
   public static final String DATETIME_FORMAT_DURATION = "dd.HH:mm:ss";

   /** The format is "hh:mm, dd.MM.yyyy". */
   public static final String DATETIME_FORMAT = "HH:mm, dd.MM.yyyy";

   /** The format is "HH:mm 'Uhr', dd.MM.yyyy". */
   public static final String DATETIME_WITH_OCLOCK_FORMAT = "HH:mm 'Uhr', dd.MM.yyyy";

   /** The format is "dd.MM.yyyy". */
   public static final String DATE_FORMAT = "dd.MM.yyyy";

   /** The format is "dd.MM.yy". */
   public static final String DATE_SHORT_FORMAT = "dd.MM.yy";

   /** The format is "ddMMyy" */
   public static final String DATE_SHORT_FORMAT_NO_SEPARATOR = "ddMMyy";

   /** The format is "ddMMyyyy" */
   public static final String DATE_FORMAT_NO_SEPARATOR = "ddMMyyyy";

   /** The format is "dd.MM" as the year the current year is taken */
   public static final String DATE_MONTH_FORMAT = "dd.MM";

   /**
    * The format is "yyyy-MM-dd'T'HH:mm:ss". The symbol 'T' is a divider that
    * separates date and time.
    */
   public static final String DATETIME_FORMAT_ISO8601 = "yyyy-MM-dd'T'HH:mm:ss";

   public static final int MILLISECONDS_PER_MINUTE = 60000;
   public static final int SECONDS_PER_MINUTE = 60;
   public static final int MINUTES_PER_HOUR = 60;
   public static final int HOURS_PER_DAY = 24;

   /**
    * Parses the given String according to the provided format. Ensure that the
    * to be parsed string matches the given format. If either the given dateTime
    * or the format are null, the parsing is skipped and null is directly
    * returned.
    * 
    * @return a {@link Calendar} representing the parsing result; or null if
    *         either argument was null;
    * @throws ParseException
    *            if the parsing of the given dateTime failed.
    */
   public static Calendar parse(String dateTime, String format) throws ParseException {
	 if (format != null && dateTime != null) {
	    DateFormat dateFormat = new SimpleDateFormat(format);
	    Calendar result = Calendar.getInstance();
	    result.setTime(dateFormat.parse(dateTime));
	    return result;
	 }
	 return null;
   }

   /**
    * Attempts to parse the given time into a {@link Calendar} object.
    * Acceptable formats are: HH:mm and HHmm as well as Hmm - whereas in the
    * last case a leading zero is automatically prepended to convert it to the
    * HHmm format.
    * 
    * @param timeString
    *           the Time as a String either in the format HH:mm, H:mm HHmm or
    *           Hmm. If null is passed, null is returned.
    * @return a Calendar object if parsing the given time was successful; throws
    *         an Exception otherwise.
    * @throws ParseException
    *            on error.
    */
   public static Calendar parseTime(String timeString) throws ParseException {
	 if (timeString != null && !timeString.isEmpty()) {
	    if (RegexUtils.matchesExactly(TIME_WITH_SEPARATOR_PARSE_EXPRESSION, timeString)) {
		  // Provided value is in the format HH:mm
		  return DateTimeUtils.parse(timeString, TIME_FORMAT);

	    } else if (RegexUtils.matchesExactly(TIME_SINGLE_HOUR_WITH_SEPARATOR_PARSE_EXPRESSION,
			timeString)) {
		  // Prepend a zero to make it match H:mm
		  return DateTimeUtils.parse("0" + timeString, TIME_FORMAT);

	    } else if (RegexUtils.matchesExactly(TIME_WITHOUT_SEPARATOR_PARSE_EXPRESSION, timeString)) {
		  // Provided value is in the format HHmm
		  return DateTimeUtils.parse(timeString, TIME_FORMAT_NO_SEPARATOR);

	    } else if (RegexUtils.matchesExactly(TIME_SINGLE_HOUR_WITHOUT_SEPARATOR_PARSE_EXPRESSION,
			timeString)) {
		  // Prepend a zero to make it match Hmm
		  return DateTimeUtils.parse("0" + timeString, TIME_FORMAT_NO_SEPARATOR);

	    } else {
		  throw new ParseException("Invalid time " + timeString, 0);
	    }
	 }
	 // Null input maps to a null-Calendar
	 return null;
   }

   /**
    * Attempts to parse the given date into a {@link Calendar} object.
    * Acceptable formats are: dd.MM.yyyy, dd.MM.yy, ddMMyy and ddMMyyyy.
    * 
    * @param dateString
    *           the Date as a String either in the format dd.MM.yyyy, dd.MM.yy,
    *           ddMMyy or ddMMyyyy. If null is passed, null is returned.
    * @return a Calendar object if parsing the given time was successful; throws
    *         an Exception otherwise.
    * @throws ParseException
    *            on error.
    */
   public static Calendar parseDate(String dateString) throws ParseException {
	 // TODO consider locale --> US/UK enter date differently than EU
	 if (dateString != null) {
	    if (RegexUtils.matchesExactly(DATE_REGEX_FORMAT_WITH_SEPARATOR, dateString)) {
		  // Provided value is in the format dd.MM.yy or dd.MM.yyyy
		  if (dateString.length() == 8) {
			// Provided value is in the format dd.MM.yy
			return DateTimeUtils.parse(dateString, DATE_SHORT_FORMAT);
		  } else {
			// Provided value is in the format dd.MM.yyyy
			return DateTimeUtils.parse(dateString, DATE_FORMAT);
		  }
	    } else if (RegexUtils.matchesExactly(DATE_REGEX_FORMAT_WITHOUT_SEPARATOR, dateString)) {
		  // Provided value is in the format ddMMyy or ddMMyyyy
		  if (dateString.length() == 6) {
			// Provided value is in the format ddMMyy
			return DateTimeUtils.parse(dateString, DATE_SHORT_FORMAT_NO_SEPARATOR);
		  } else {
			// Provided value is in the format ddMMyyyy
			return DateTimeUtils.parse(dateString, DATE_FORMAT_NO_SEPARATOR);
		  }
	    } else if (StringUtils.EMPTY.equals(dateString)) {
		  // Empty input maps to a null-Calendar
		  return null;
	    } else {
		  throw new ParseException("Invalid date " + dateString, 0);
	    }
	 }
	 // Null input maps to a null-Calendar
	 return null;
   }

   /**
    * Formats the given Date according to the provided format.
    * 
    * @param dateTime
    *           the {@link Calendar} that should be formatted as string.
    * @param format
    *           that should be used to convert the given calendar.
    * @return the given Calendar formatted as a {@link String}; or null if
    *         either argument was null.
    * @throws ParseException
    *            if parsing fails.
    */
   public static String format(Calendar dateTime, String format) {
	 if (format != null && dateTime != null) {
	    DateFormat dateFormat = new SimpleDateFormat(format);
	    return dateFormat.format(dateTime.getTime());
	 }
	 return null;
   }

   public static Date now() {
	 return new Date();
   }

   /**
    * Converts the given {@link Date} to a {@link Calendar}, provided the given
    * date is not null.
    * 
    * @param date
    *           the date that should be converted to calendar.
    * @return the calendar that represents the given date; or null if the given
    *         date was null.
    */
   public static Calendar toCalendar(Date date) {
	 if (date != null) {
	    Calendar cal = Calendar.getInstance();
	    cal.setTime(date);
	    return cal;
	 }
	 return null;
   }

   public static Date toDate(Calendar calendar) {
	 return calendar != null ? calendar.getTime() : null;
   }

   /**
    * Calculates the time difference between start and end time in milliseconds.
    * 
    * @param startTime
    *           the starting time
    * @param endTime
    *           the ending time
    * @return a positive long if start is before end, a negative long if end is
    *         before start, {@link Long#MIN_VALUE} if either value is null.
    */
   public static long getDifferenceInMilliseconds(Date startTime, Date endTime) {

	 if (startTime == null || endTime == null) {
	    String errorMessage = "Cant calculate difference if either start or end time are null.";
	    LogCat.w(TAG, errorMessage);
	    return Long.MIN_VALUE;
	 }

	 if (startTime.before(endTime)) {
	    return endTime.getTime() - startTime.getTime();
	 } else {
	    String errorMessage = "End time is before start time.";
	    LogCat.w(TAG, errorMessage);
	    // TODO good idea to silently reverse?
	    // reverse values and negate the result
	    return (calculateDifferenceInMinutes(endTime, startTime) * -1);
	 }

   }

   /**
    * Calculates the time difference between start and end time in minutes.
    * 
    * @param startTime
    *           the starting time
    * @param endTime
    *           the ending time
    * @return a positive long if start is before end, a negative long if end is
    *         before start, {@link Long#MIN_VALUE} if either value is null.
    */
   public static long getDifferenceInMinutes(Date startTime, Date endTime) {
	 if (startTime == null || endTime == null) {
	    String errorMessage = "Cant calculate difference if either start or end time are null.";
	    LogCat.w(TAG, errorMessage);
	    return Long.MIN_VALUE;
	 }

	 if (startTime.before(endTime)) {
	    return calculateDifferenceInMinutes(startTime, endTime);
	 } else {
	    String errorMessage = "End time is before start time.";
	    LogCat.w(TAG, errorMessage);
	    // TODO good idea to silently reverse?
	    // reverse values and negate the result
	    return (calculateDifferenceInMinutes(endTime, startTime) * -1);
	 }
   }

   /**
    * 
    * @param start
    *           start time in milliseconds, needs to be before end.
    * @param end
    *           end time in milliseconds, needs to be after start.
    * @return the difference between start and end in minutes.
    * @throws IllegalArgumentException
    *            if start time is greater end time or either value is null.
    * 
    */
   public static long getDifferenceinMinutes(Calendar start, Calendar end)
	    throws IllegalArgumentException {
	 if (start == null) {
	    throw new IllegalArgumentException("Start time may not be null.");
	 } else if (end == null) {
	    throw new IllegalArgumentException("End time may not be null.");
	 } else if (end.before(start)) {
	    throw new IllegalArgumentException("Start time may not be before end time.");
	 }

	 return ((end.getTimeInMillis() - start.getTimeInMillis()) / MILLISECONDS_PER_MINUTE);
   }

   /**
    * 
    * @param start
    *           the starting time.
    * @param end
    *           the ending time.
    * @return the difference in days between start and end.
    * @throws IllegalArgumentException
    *            if either start or end are null.
    */
   public static int getDifferenceInDays(Calendar start, Calendar end) {
	 if (start != null && end != null) {
	    // Ensure that the start date-time is before the end date-time
	    if (start.before(end)) {
		  int daysInBetween = 0;
		  // Clone so we don't change the original value
		  Calendar startDate = (Calendar) start.clone();
		  while (startDate.before(end)) {

			if (startDate.get(Calendar.DAY_OF_MONTH) == end.get(Calendar.DAY_OF_MONTH)) {
			   // Start and end are on the same day...
			   if (startDate.get(Calendar.MONTH) == end.get(Calendar.MONTH)) {
				 // ... and in the same month...
				 if (startDate.get(Calendar.YEAR) == end.get(Calendar.YEAR)) {
				    // ... and in the same year, so the dates are
				    // the same,
				    // don't increment the days and stop immediately
				    return daysInBetween;
				 }
			   }
			}

			startDate.add(Calendar.DAY_OF_MONTH, 1);
			daysInBetween++;
		  }
		  return daysInBetween;
	    }
	 }
	 throw new IllegalArgumentException(
		  "Neither start nor end date may be null. Start must be before end.");
   }

   /**
    * Requires both values to be valid (not null) and start time before end
    * time.
    * 
    * @param startTime
    *           the start time
    * @param endTime
    *           the end time
    * @return the difference between start and end in minutes
    */
   private static long calculateDifferenceInMinutes(Date startTime, Date endTime) {
	 long differenceInMilliseconds = endTime.getTime() - startTime.getTime();
	 // Formula to obtain minutes from milliseconds
	 return differenceInMilliseconds / MILLISECONDS_PER_MINUTE;
   }

   /**
    * Merges the given time with the given Date object and returns result.
    * 
    * @param dateTime
    *           contains date-time information.
    * @param timeString
    *           must be provided in the format {@link DateTimeUtils#TIME_FORMAT}
    *           .
    * @return the resulting {@link Date} object; or the original date object, if
    *         either the timeString was null or the timeString could not be
    *         parsed.
    */
   public static Date mergeTime(Date dateTime, String timeString) {
	 try {
	    if (timeString != null && !timeString.isEmpty()) {
		  Calendar time = parseTime(timeString);
		  return mergeTime(dateTime, time);
	    }

	 } catch (ParseException e) {
	    LogCat.e(TAG, "Merging time String into Date object failed.", e);
	 }
	 // TODO maybe we should return null instead?
	 return dateTime;
   }

   /**
    * Merges the given date with the given Date object and returns result.
    * 
    * @param dateTime
    *           contains date-time information.
    * @param dateString
    *           must be provided in the format
    *           {@link DateTimeUtils#DATE_SHORT_FORMAT} .
    * @return the resulting {@link Date} object; or the original date object, if
    *         either the dateString was null or could not be parsed.
    */
   public static Date mergeDate(Date dateTime, String dateString) {
	 try {
	    if (dateString != null && !dateString.isEmpty()) {
		  // TODO make sure we don't need to manipulate the date/month
		  Calendar date = parseDate(dateString);
		  // Update time object with new date
		  return mergeDate(dateTime, date);
	    }

	 } catch (ParseException e) {
	    LogCat.e(TAG, "Merging date String into Date-Time object failed.", e);
	 }
	 // TODO maybe we should return null instead?
	 return dateTime;
   }

   /**
    * Merges the given date into the given dateTime.
    * 
    * @param dateTime
    *           Serves as a base for the merge.
    * @param date
    *           the date information (month, day, year) of this instance are
    *           merged into the given dateTime.
    * @return the given dateTime with the given date merged into it (date
    *         information only).
    */
   public static Date mergeDate(Date dateTime, Calendar date) {
	 if (dateTime != null && date != null) {
	    Calendar result = toCalendar(dateTime);
	    result.set(Calendar.DAY_OF_MONTH, date.get(Calendar.DAY_OF_MONTH));
	    result.set(Calendar.MONTH, date.get(Calendar.MONTH));
	    result.set(Calendar.YEAR, date.get(Calendar.YEAR));

	    return toDate(result);
	 }
	 return null;
   }

   /**
    * Merges the given time into the given dateTime.
    * 
    * @param dateTime
    *           Serves as a base for the merge.
    * @param time
    *           the time information (seconds, minutes, hours etc.) of this
    *           instance are merged into the given dateTime.
    * @return the given dateTime with the given time merged into it (time
    *         information only).
    */
   public static Date mergeTime(Date dateTime, Calendar time) {
	 if (dateTime != null && time != null) {
	    Calendar result = toCalendar(dateTime);
	    result.set(Calendar.HOUR_OF_DAY, time.get(Calendar.HOUR_OF_DAY));
	    result.set(Calendar.MINUTE, time.get(Calendar.MINUTE));
	    return toDate(result);
	 }
	 return null;
   }

   /**
    * Adds the given timespan to the given {@link Date}.
    * 
    * @param dateTime
    *           the date time to which the given time span should be added.
    *           Passing null will cause an {@link AssertionFailedError} to be
    *           thrown.
    * @param timeSpan
    *           the time span must be given in the format
    *           {@link DateTimeUtils#TIMESPAN_REGEX_WITH_SEPARTOR}. Passing null
    *           will cause an {@link AssertionFailedError} to be thrown.
    * @return the resulting {@link Calendar} instance.
    * @throws AssertionFailedError
    *            if either dateTime or timeSpan are null; or if timeSpan is not
    *            in the format
    *            {@link DateTimeUtils#TIMESPAN_REGEX_WITH_SEPARTOR}.
    */
   public static Calendar addTimespan(Date dateTime, String timeSpan) throws AssertionFailedError {

	 assertNotNull("DateTime may not be null", dateTime);

	 // Convert to calendar so we can add the timespan
	 Calendar result = DateTimeUtils.toCalendar(dateTime);

	 int[] timeSpanParts = extractTimeSpanParts(timeSpan);

	 result.add(Calendar.DAY_OF_MONTH, timeSpanParts[0]);
	 result.add(Calendar.HOUR_OF_DAY, timeSpanParts[1]);
	 result.add(Calendar.MINUTE, timeSpanParts[2]);
	 result.add(Calendar.SECOND, timeSpanParts[3]);

	 return result;
   }

   /**
    * Subtracts the given timespan from the given {@link Date}.
    * 
    * @param dateTime
    *           the date time from which the given time span should be
    *           subtracted. Passing null will cause an
    *           {@link AssertionFailedError} to be thrown.
    * @param timeSpan
    *           the time span must be given in the format
    *           {@link DateTimeUtils#TIMESPAN_REGEX_WITH_SEPARTOR}. Passing null
    *           will cause an {@link AssertionFailedError} to be thrown.
    * @return the resulting {@link Calendar} instance.
    * @throws AssertionFailedError
    *            if either dateTime or timeSpan are null; or if timeSpan is not
    *            in the format
    *            {@link DateTimeUtils#TIMESPAN_REGEX_WITH_SEPARTOR}.
    */
   public static Calendar subsctractTimespan(Date dateTime, String timeSpan)
	    throws AssertionFailedError {
	 assertNotNull("DateTime may not be null", dateTime);

	 // Convert to calendar so we can add the timespan
	 Calendar result = DateTimeUtils.toCalendar(dateTime);

	 int[] timeSpanParts = extractTimeSpanParts(timeSpan);

	 // The subtract is achieved by a negative add
	 result.add(Calendar.DAY_OF_MONTH, timeSpanParts[0] * -1);
	 result.add(Calendar.HOUR_OF_DAY, timeSpanParts[1] * -1);
	 result.add(Calendar.MINUTE, timeSpanParts[2] * -1);
	 result.add(Calendar.SECOND, timeSpanParts[3] * -1);

	 return result;
   }

   /**
    * 
    * @param timeSpan
    * @return an int array with the following values (from index zero to four):
    *         [days, hours, minutes, seconds].
    * @throws AssertionFailedError
    *            if either timeSpan is null; or if timeSpan is not in the format
    *            {@link DateTimeUtils#TIMESPAN_REGEX_WITH_SEPARTOR}.
    */
   public static int[] extractTimeSpanParts(String timeSpan) throws AssertionFailedError {

	 assertNotNull("Timespan may not be null", timeSpan);
	 assertTrue("Illegal format for timeSpan: " + timeSpan,
		  RegexUtils.matchesExactly(DateTimeUtils.TIMESPAN_REGEX_WITH_SEPARTOR, timeSpan));

	 // Separate the timeSpan in days, hours, minutes etc...
	 String[] timeSpanParts = RegexUtils.parse(DateTimeUtils.TIMESPAN_REGEX_WITH_SEPARTOR,
		  timeSpan);
	 assertEquals("TimeSpan must consist of four parts: Days, hours, minutes and seconds", 4,
		  timeSpanParts.length);

	 return new int[] { Integer.valueOf(timeSpanParts[0]), Integer.valueOf(timeSpanParts[1]),
		  Integer.valueOf(timeSpanParts[2]), Integer.valueOf(timeSpanParts[3]) };
   }

   /**
    * Formats the given time in milliseconds using the given format and
    * {@link TimeZone}.
    * 
    * @param timeInMs
    *           the time in milliseconds that should be formatted.
    * @param format
    *           the format that should be used for formatting.
    * @param timeZone
    *           the timezone for which the time should be formatted.
    * @return the result.
    */
   public static String format(long timeInMs, String format, TimeZone timeZone) {
	 DateFormat formatter = new SimpleDateFormat(format);
	 formatter.setTimeZone(timeZone);
	 return formatter.format(new Date(timeInMs));
   }

   /**
    * 
    * @return true if time is in a daylight savings time period for this time
    *         zone; false otherwise.
    */
   public static boolean inDaylightTime(TimeZone timeZone) {
	 return timeZone != null ? timeZone.inDaylightTime(new Date()) : false;
   }

}
